Skip to content

Learned heuristics: imitate solved plans, then optimise search cost directly - #157

Merged
guilyx merged 5 commits into
mainfrom
claude/library-demo-plots-webpage-8r8uz4
Jul 31, 2026
Merged

Learned heuristics: imitate solved plans, then optimise search cost directly#157
guilyx merged 5 commits into
mainfrom
claude/library-demo-plots-webpage-8r8uz4

Conversation

@guilyx

@guilyx guilyx commented Jul 31, 2026

Copy link
Copy Markdown
Member

Every solved instance is already a labelled trajectory: the cost of a plan's suffix from a state on it is that state's cost-to-go. jupyddl learn turns a corpus of those into a heuristic, then stops imitating h* and starts optimising the thing that actually matters — the number of nodes search expands.

jupyddl learn blocksworld --sizes 3-6 --seeds-per-size 3 \
    --cem 10 --cem-sizes 9-12 --evaluate 9-13 -o bw.heur.json

jupyddl solve domain.pddl problem.pddl -s gbfs -H learned:bw.heur.json

Note: one claim in an earlier version of this description was wrong and has been corrected — see the correction comment. The substantive results are unaffected; the attribution of one of them was not.

Result

Trained on 3–6 block instances, evaluated on 9–13 block instances from a seed family no stage of training saw, under greedy best-first search:

heuristic coverage expansions seconds plan cost
learned 1.00 137 0.038 48.2
hff 1.00 518 0.561 51.8
goalcount 1.00 2483 0.169 51.0
blind 0.00

3.8× fewer expansions than hff and 15× faster. About a minute end to end on one CPU.

Read that mean with care. The held-out set has a heavy tail: nine of ten instances sit between 58 and 227 expansions, and the tenth (blocksworld-13-7777) moves the average on its own. The median improvement over imitation is ~1.4×. The most defensible single claim is the coverage one — imitation could not solve that instance inside 30 000 expansions and the tuned heuristic solves it in 214.

Three design decisions did most of the work

Features are keyed on the predicate symbol, never the ground atom, and normalised per symbol. That is the entire transfer story — a one-hot over ground atoms changes length and the meaning of every slot with each instance, so a model trained on four blocks cannot even be evaluated on forty.

The objective is ranking, not regression. GBFS never reads a heuristic value, only the order it imposes: a model uniformly 30 too high guides perfectly while scoring terribly on RMSE, and a model with excellent RMSE that inverts two siblings sends the search into the wrong subtree. Checkpoints are selected on top-1 accuracy for the same reason. (Chrestien et al., NeurIPS 2023.)

Nodes expanded is not differentiable — it comes out the far side of a priority queue — so the reinforcement stage reaches it three ways: DAgger for the distribution shift, bootstrapping for instances too hard to label, and the cross-entropy method over the weight vector with the planner as a black box.

What the measurements actually showed

CEM must tune on instances with headroom. On the training ladder the imitated heuristic already expands about as many nodes as the plan is long, so every perturbation scores identically and the objective is flat. Tuning there moved the score 12.8 → 12.2 — noise. Tuning a rung higher moved 152 → 101.

The perturbation scale is the knob that matters. σ=0.05 and σ=0.15 differ by an order of magnitude in held-out cost (1734 vs 137) on otherwise identical runs, and the difference is concentrated entirely in the single hardest instance.

Selecting the incumbent on a disjoint instance family is a guardrail, not the knob. It guarantees the returned model is no worse on instances the optimiser did not fit (validation 276 → 73 on this run), and it costs one scoring pass per iteration. It is worth keeping. It is not what produced the headline number, and an earlier version of this description said it was.

Both scores are printed every iteration, so a run fitting its tuning set while losing validation is visible rather than silent.

It does not always win

On logistics it loses to hff by 6× (204 expansions vs 35), and the reason is exact rather than mysterious: that domain has two predicates, so the feature vector is eight numbers and cannot distinguish which package is where, only how many are somewhere. Two states with a package at its destination and across the map are identical under it. Top-1 accuracy 0.656.

That is the argument for relational representations stated as a measurement, and it is the first item on the roadmap.

Integration

learned:<model.json> resolves anywhere a heuristic name is accepted — solve, benchmark, the API — through a lazy loader, so nothing in the core imports the learning stack and a planner that never asks for one never pays for it. make_heuristic also passes an already-built heuristic through, so callers holding a trained model need not round-trip it to disk.

The learning stack is stdlib-only like the rest of the core, verified by training in an environment with no NumPy installed. The learn extra adds NumPy purely for speed (one to two orders of magnitude); a test pins the two implementations to identical gradients, and both are checked against finite differences.

Verification

  • 414 tests pass, 53 of them new. flake8 and black clean.
  • Gradients verified against finite differences; NumPy and pure-Python paths asserted identical.
  • Plans found with a learned heuristic are asserted to still validate — the heuristic may be wrong, the planner may not become unsound.
  • LearnedHeuristic.admissible is False and the docs say so: nothing in the objective bounds the prediction from above, so pair it with gbfs or wastar, never with an optimality claim.

Research notes

.docs/ carries the write-up: prior work and where this sits in it, the measured results including the logistics failure, the MDP the RL stage corresponds to and why the obvious policy gradient is harder than it looks, the corrections above with the per-instance data behind them, and a roadmap ordered by expected value.

Every solved instance is already a labelled trajectory: the cost of a
plan's suffix from a state on it is that state's cost-to-go. `jupyddl
learn` turns a corpus of those into a heuristic and then stops imitating
h* and starts optimising the thing that actually matters.

Trained on 3-6 block instances, evaluated on 9-13 block instances from a
seed family no stage of training saw, under greedy best-first search:

    heuristic     coverage   expanded   seconds   cost
    learned           1.00        137     0.038   48.2
    hff               1.00        518     0.561   51.8
    goalcount         1.00       2483     0.169   51.0

Three design decisions did most of that work.

**Features are keyed on the predicate symbol, never the ground atom**,
and normalised per symbol. That is the entire transfer story: a one-hot
over ground atoms changes length and meaning with every instance, so a
model trained on four blocks cannot even be evaluated on forty.

**The objective is ranking, not regression.** Greedy best-first search
never reads a heuristic value, only the order it imposes; a model
uniformly 30 too high guides perfectly and scores terribly on RMSE.
Checkpoints are selected on top-1 accuracy for the same reason. A small
regression term stays only to anchor a scale, which pure ranking leaves
undefined and weighted A* needs.

**Nodes expanded is not differentiable**, so the reinforcement stage
reaches it three ways: DAgger for the distribution shift, bootstrapping
for instances too hard to label, and the cross-entropy method over the
weight vector with the planner as a black box.

Two findings worth recording, both measured rather than reasoned:

- CEM must tune on instances with headroom. On the training ladder the
  imitated heuristic already expands about as many nodes as the plan is
  long, so every perturbation scores the same. Tuning there moved the
  score 12.83 -> 12.75; tuning a rung higher moved it 1605 -> 64.
- CEM must select on instances it is not fitting. The first version
  selected on its tuning set, reported 108 expansions, and scored 1734
  on held-out instances -- nearly five times worse than the imitated
  heuristic it started from. A thousand parameters had been fitted to
  eight instances and had fitted them. Selecting the incumbent on a
  disjoint instance family brings the same command to 137.

It does not always win. On logistics it loses to hff by 6x, and the
reason is exact: that domain has two predicates, so the feature vector
is eight numbers and cannot distinguish which package is where, only how
many are somewhere. Two states with a package at its destination and
across the map are identical under it. That is the argument for
relational representations stated as a measurement.

The learning stack is stdlib-only like the rest of the core -- verified
by training in an environment with no numpy installed. The numpy path is
a speed option worth one to two orders of magnitude, and a test pins the
two implementations to identical gradients, both checked against finite
differences.

`learned:<model.json>` resolves anywhere a heuristic name is accepted,
via a lazy loader, so nothing in the core imports the learning stack and
a planner that never asks for one never pays for it.

.docs/ carries the research notes: prior work, the measured results
including the failure, the MDP the RL stage corresponds to, and what to
build next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT
@mergify
mergify Bot requested a review from sampreets3 July 31, 2026 14:59

guilyx commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Correction to a claim in the PR description

While building a promo video for this work — which re-measures everything rather than quoting the notes — one of the headline claims failed to reproduce. It was wrong, and both the description and .docs/ are being corrected.

What the description said

CEM must select on instances it is not fitting. [...] Selecting the incumbent on a disjoint instance family brings the same command to 137.

What is actually true

Two things changed in the same edit: the validation split went in, and the perturbation scale sigma went from 0.05 to 0.15. I credited the whole improvement to the first. Re-running with one variable at a time:

held-out mean expansions
σ=0.05, selected on the tuning set 1734
σ=0.05, selected on a disjoint family 1730
σ=0.15, selected on the tuning set 137
σ=0.15, selected on a disjoint family 137

The validation split makes no measurable difference here. sigma was doing all the work. Widening the validation family to span sizes past the tuning range doesn't change it either (1741 / 136).

Which is the exact mistake I'd written a code comment warning about, one commit earlier, in the collection script for this video.

And the mean was doing the lying

Per instance, held-out set:

instance imitation σ=0.05 σ=0.15
blocksworld-09-7777 77 69 70
blocksworld-09-7778 126 67 68
blocksworld-10-7777 70 33 58
blocksworld-10-7778 287 229 217
blocksworld-11-7777 113 90 81
blocksworld-11-7778 150 121 111
blocksworld-12-7777 349 228 227
blocksworld-12-7778 114 72 70
blocksworld-13-7777 30 000 (unsolved) 16 121 214
blocksworld-13-7778 2 004 273 255

Nine of ten instances improve under both settings by roughly the same factor. The entire 1734-vs-137 gap is one instance.

What survives

The substantive results are unaffected:

  • CEM fixed a real coverage failure. Imitation could not solve blocksworld-13-7777 inside 30 000 expansions; both tuned versions could. That is a capability change, not a shaved constant.
  • Every held-out instance improves, median around 1.4× — the honest version of the headline.
  • The 137 vs hff's 518 comparison stands (both 10/10 coverage), but 137 is a mean over a heavy tail and should be read as such, not as a typical case.
  • The validation split is still correct and still worth keeping. It guarantees the returned model is no worse on instances the optimiser did not fit — on this run it took validation 276 → 73. It is a guardrail, not the knob. I just should not have credited it with a result it did not produce.

No code changes; the mechanism is sound. .docs/rl-for-search.md now carries the correction, the per-instance table, and the general lesson — change one thing at a time, and look at the distribution before believing the mean.


Generated by Claude Code

claude added 3 commits July 31, 2026 16:51
`tools/make_learn_promo.py` renders a 97-second tour of the learned
heuristic and the reinforcement stage. Like the main promo it measures
everything at render time -- it trains, runs CEM, and re-runs both
failure modes -- so the video cannot drift from the notes. That is not
decoration: building it is what caught the errors below.

Eleven scenes: a plan handing over its own labels, imitation, why the
ordering is the thing search reads, the MDP, CEM descending, and then
the two traps, the result, and the domain where it loses.

Two corrections to claims already published in .docs/ and on the PR.

**The validation split was not what improved transfer.** Two settings
changed in one edit -- the split went in, and sigma went 0.05 -> 0.15 --
and the improvement was credited entirely to the first. Varying one at a
time:

    sigma 0.05, selected on the tuning set     1734
    sigma 0.05, selected on a disjoint family  1730
    sigma 0.15, selected on the tuning set      137
    sigma 0.15, selected on a disjoint family   137

sigma was doing all of it. Widening the validation family to span sizes
past the tuning range does not change that either. The split is still
correct and still worth its one extra scoring pass -- it bounds what can
be returned, and took validation 276 -> 73 on this run -- but it is a
guardrail, not the knob, and I should not have credited it with someone
else's result.

**And the mean was doing the lying.** Per instance on the held-out set,
nine of ten improve under both settings by roughly the same factor. The
entire 1734-versus-137 gap is blocksworld-13-7777, which imitation could
not solve inside 30000 expansions at all. A mean over that distribution
is close to a report of one instance.

What survives is the coverage claim, which was always the strongest one:
imitation fails that instance, both tuned versions solve it, and the
median improvement across the set is about 1.4x.

The general lesson is the ordinary one, which the derivative-free
framing made easy to forget: change one thing at a time, and look at the
distribution before believing the mean. It is now in AGENTS.md so the
next person does not have to rediscover it.

promo/rl-data.json caches the measurement pass; delete it to re-measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT
CodeFactor flagged one issue on the previous commit: `collect()` at 61
statements against a limit of 50, with 54 locals. It was the only pylint
rule class present in make_learn_promo.py and not already in
make_promo.py, which is how it was identified -- CodeFactor's own report
needs an account to read.

The complaint was fair. One function was building four instance
families, annotating a plan, training, reinforcing, reproducing two
failure modes and measuring a second domain. It is now six functions
that each do one of those, plus an orchestrator that reads as a summary
of what the video contains.

`_transfer` also collapses six near-identical `evaluate_transfer` calls
that were repeating the same budget and time limit; those are now
module-level constants, so changing the budget changes it everywhere
rather than in five of six places.

Behaviour is unchanged, and checked rather than assumed: re-running the
full measurement pass produces a byte-identical cache apart from
wall-clock timings, which vary run to run. Every expansion count,
coverage figure and plan cost matches. The video is not re-rendered --
the scene functions were not touched and the data behind them is the
same.

No remaining pylint rule class is unique to this file relative to the
promo renderer already on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT
Three markdownlint findings in the files this branch adds: two fenced
blocks in .docs/rl-for-search.md with no language, and one block in
.docs/README.md indented where the rest of the file fences. Fixed on
their own merits -- a fence without a language gets no highlighting and
no copy affordance on GitHub.

Not claimed as the CodeFactor fix. Its report needs an account to read
and I could not reproduce a single-issue result locally with pylint,
bandit or markdownlint at their defaults, all of which return far more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT

guilyx commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

On the CodeFactor status

Flagging this rather than silently chasing it: CodeFactor's report is not readable without an account (both /repository/.../pull/157 and the repo page return 403), so all I have is the status line, "1 issue found."

I tried to identify it by reproducing locally and could not, because every analyzer CodeFactor plausibly runs returns far more than one issue on this diff at its defaults:

analyzer findings on this PR's changed files
pylint (E+W only) ~30
bandit ~100 (mostly B101 assert_used in the test file)
markdownlint several hundred (mostly MD013 line length, largely pre-existing)

So whatever ruleset CodeFactor uses is a curated subset I can't infer from a single integer.

Two things I did fix

  • collect() in tools/make_learn_promo.py was 61 statements against pylint's 50, and 54 locals — the one rule class present in that file and not in the promo renderer already on main. It is now six single-purpose functions. Re-running the full measurement pass produced a byte-identical cache apart from wall-clock timings, so the refactor is behaviour-preserving, and the video did not need re-rendering.
  • Three markdownlint findings in the docs this branch adds: two code fences with no declared language, one indented block in a file that fences everything else.

Neither is claimed as the fix. Both are improvements on their own merits.

Why I am not driving this one to green

Because red CodeFactor appears to be this repository's steady state rather than a regression signal:

I do not think it is right for me to keep shotgunning speculative changes at an opaque count, and I would rather not touch .mergify.yml's check-success=CodeFactor condition to work around it — that is a repo-policy decision, not mine.

If someone with CodeFactor access pastes the finding here, I will fix it directly. Everything else on the PR is green: build, and all seven test matrix jobs.


Generated by Claude Code

Found reviewing this branch before merge. `_default_labeller` caches one
hff instance per task, keyed on `id(task)` because `Task` is an unfrozen
dataclass and therefore unhashable. Nothing held a reference to the task,
and CPython reuses addresses once an object is collected -- so a caller
passing tasks the cache does not keep alive could be handed another
task's relaxed-task tables. That would not raise. It would quietly
mislabel the aggregated samples, which is the failure mode this whole
module is least able to notice.

Unreachable as things stand: `dagger` holds its task list for the whole
loop. But `labeller` is a public parameter, and the property that makes
this safe lives in a different function from the code that depends on it.

The entry now holds the task, which makes the id un-recyclable while the
entry lives, plus an identity check so it stays correct even if that
reasoning is ever wrong. Test replays each returned plan against the task
it was requested for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Meb35zHKsyBkH2sbWoyKMT

guilyx commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Review

Reviewed by risk rather than line by line: the newest logic (jupyddl/learn/), the correctness boundary where a learned heuristic meets planners that assume things about h, and the integration points that let it reach every caller.

One issue found and fixed — 684f5b0

_default_labeller cached on a recyclable id(). It builds one hff per task and keys the cache on id(task), because Task is an unfrozen dataclass and therefore unhashable. Nothing held a reference to the task, and CPython reuses addresses once an object is collected — so a caller passing tasks the cache does not keep alive could be handed another task's relaxed-task tables.

That would not raise. It would silently mislabel the aggregated DAgger samples, which is precisely the failure this module is least equipped to notice: the labels look plausible, training succeeds, and the heuristic is quietly worse.

Unreachable as things stand — dagger holds its task list for the whole loop — but labeller is a public parameter, and the property that makes it safe lives in a different function from the code depending on it. The entry now holds the task (so the id cannot be recycled while it lives) plus an identity check. The test replays each returned plan against the task it was requested for.

What holds up

  • The heuristic cannot make a planner unsound. It is not admissible and does not claim to be; LearnedHeuristic.admissible is False and the docs route users to gbfs/wastar. The suite asserts plans still validate — the invariant that does hold.
  • A goal state always scores zero, whatever the network learned, so f-values near the goal are not distorted.
  • Output through softplus, not max(0, x). Both keep h ≥ 0; only softplus keeps a gradient below the threshold, so a unit that lands there early can recover.
  • The zero-dependency promise survives. Verified by training in an environment with no NumPy installed, not by reading the import guard. tests/test_learn.py pins the two code paths to identical gradients, and both against finite differences.
  • Nothing in the core imports the learning stacklearned: resolves lazily inside the loader, so a planner that never asks for one never pays for it.
  • web/dist is unaffected, confirmed by rebuilding after the fix: learn/ is in SKIP_DIRS, so the browser bundle does not grow.

Smaller notes, not blocking

  • dagger and bootstrap mutate the corpus passed to them rather than returning a copy. Documented and tested, so deliberate — but the signature returning (bundle, corpus, history) reads as though the corpus were new.
  • Operator.cost is still annotated int while preference weights put floats through it. Pre-existing from Observable search, most of PDDL 3, and a browser research workbench #151, noted there, unchanged here.

On CodeFactor

Red, at "1 issue found." I could not identify it — the report is 403 without an account, and pylint/bandit/markdownlint all return far more than one on this diff at their defaults, so the ruleset is a curated subset I cannot infer from an integer. Details and what I fixed anyway are in the comment above.

Worth stating plainly for the record: #151 was merged at "11 issues found." Red CodeFactor is this repository's steady state, not a signal this branch regressed something. If someone with access pastes the finding, I will fix it.

Verdict

Approve and merge. 415 tests green on Python 3.9 through 3.14, build green (wheel builds, installs clean and plans from outside the repo), flake8 and black clean.

The results are stated with their limits attached: the headline 137 is a mean over a heavy tail dominated by one instance, the strongest claim is the coverage one, and the domain where this loses has its mechanism spelled out rather than glossed. Two earlier claims that did not survive re-measurement are corrected in place rather than deleted.


Generated by Claude Code

@guilyx
guilyx marked this pull request as ready for review July 31, 2026 19:55
@guilyx
guilyx merged commit 3212a89 into main Jul 31, 2026
10 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants